package buildinjava.java;

import buildinjava.BuildException;
import buildinjava.io.Files;
import buildinjava.io.Urls;
import buildinjava.java.compiler.DependencyCache;
import buildinjava.java.compiler.DependencyCache.FileNode;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Lists;
import com.google.common.collect.Sets;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Writer;
import java.net.URL;
import java.net.URLClassLoader;
import java.security.AccessController;
import java.security.PrivilegedAction;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.Set;
import javax.tools.Diagnostic;
import javax.tools.DiagnosticCollector;
import javax.tools.JavaFileObject;
import javax.tools.StandardJavaFileManager;
import javax.tools.ToolProvider;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Compiles Java source-code.
 * 
 * @author Gili Tzabari
 */
public final class JavaCompiler
{
	private List<URL> classPathList = Lists.newArrayList();
	private Set<DebugType> debugList = Sets.newHashSet(
		Arrays.asList(DebugType.LINES, DebugType.SOURCE, DebugType.VARIABLES));
	/**
	 * Maps a class' binary name to its meta-data.
	 */
	private DependencyCache dependencyCache;
	private Logger log = LoggerFactory.getLogger(JavaCompiler.class);

	/**
	 * Sets the compiler classpath.
	 *
	 * @param classPath the compiler classpath
	 * @return the JavaCompiler
	 */
	public JavaCompiler classPath(List<URL> classPath)
	{
		this.classPathList = classPath;
		return this;
	}

	/**
	 * Indicates the kind of debugging information the generated files should contain.
	 *
	 * @param value the kind of debugging information the generated files should contain. By default all
	 * debugging information is generated.
	 * @return the JavaCompiler object
	 */
	public JavaCompiler debug(final Set<DebugType> value)
	{
		this.debugList = value;
		return this;
	}

	/**
	 * Compiles the source code.
	 *
	 * @param sourceFiles the source files to compile
	 * @param targetDirectory the directory to compile into
	 * @param sourcePath the source file search path
	 * @throws IllegalArgumentException if sourcePath, sourceFiles, targetDirectory are null; or if
	 * sourcePath refers to a non-existant path or a non-directory; or if sourceFiles refers to a
	 * non-existant file or a non-file; or if targetDirectory is not a directory
	 * @throws BuildException if the operation fails
	 */
	public void apply(final Collection<File> sourceFiles, final File targetDirectory,
										final Collection<File> sourcePath)
		throws IllegalArgumentException, BuildException
	{
		if (sourceFiles == null)
			throw new IllegalArgumentException("sourceFiles may not be null");
		for (File file: sourceFiles)
		{
			if (!file.exists())
				throw new IllegalArgumentException("sourceFiles refers to a non-existant file: "
																					 + file.getAbsolutePath());
			if (!file.isFile())
			{
				throw new IllegalArgumentException("sourceFiles refers to a non-file: " + file.
					getAbsolutePath());
			}
		}
		if (targetDirectory == null)
			throw new IllegalArgumentException("targetDirectory may not be null");
		if (!targetDirectory.exists())
			throw new IllegalArgumentException("targetDirectory must exist: " + targetDirectory.
				getAbsolutePath());
		if (!targetDirectory.isDirectory())
			throw new IllegalArgumentException("targetDirectory must be a directory: " + targetDirectory.
				getAbsolutePath());
		if (sourcePath == null)
			throw new IllegalArgumentException("sourcePath may not be null");
		Set<File> uniqueSourceFiles = ImmutableSet.copyOf(sourceFiles);
		Set<File> uniqueSourcePath = ImmutableSet.copyOf(sourcePath);
		for (File path: sourcePath)
		{
			if (!path.exists())
				throw new IllegalArgumentException("sourcePath refers to non-existant path: " + path.
					getAbsolutePath());
			if (!path.isDirectory())
			{
				throw new IllegalArgumentException("sourcePath refers to a non-directory: " + path.
					getAbsolutePath());
			}
		}
		final javax.tools.JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
		if (compiler == null)
		{
			throw new BuildException("javax.tools.JavaCompiler is not available. Are you running from the JRE "
															 + "instead of the JDK?");
		}
		final DiagnosticCollector<JavaFileObject> diagnostics =
			new DiagnosticCollector<JavaFileObject>();
		final StandardJavaFileManager fileManager = compiler.getStandardFileManager(diagnostics, null,
			null);
		final Iterable<? extends JavaFileObject> compilationUnits;

		Set<File> modifiedFiles;
		try
		{
			loadDependencyCache();
			modifiedFiles = getModifiedFiles(uniqueSourceFiles, uniqueSourcePath, targetDirectory,
				Sets.<File>newHashSet());
			compilationUnits = fileManager.getJavaFileObjectsFromFiles(modifiedFiles);
		}
		catch (IOException e)
		{
			throw new BuildException(e);
		}
		if (!compilationUnits.iterator().hasNext())
		{
			// no files have been modified
			return;
		}
		final List<String> options = Lists.newArrayList();
		final List<URL> classPath = Lists.newArrayList(Files.toURL().apply(targetDirectory));
		if (!classPathList.isEmpty())
		{
			options.add("-cp");
			final StringBuilder buffer = new StringBuilder();
			try
			{
				for (URL url: classPathList)
				{
					classPath.add(url);
					buffer.append(Urls.toFile().apply(url).getPath() + File.pathSeparatorChar);
				}
			}
			catch (IllegalArgumentException e)
			{
				// If the URL does not refer to a file
				throw new BuildException(e);
			}
			options.add(buffer.toString());
		}

		final StringBuilder debugLine = new StringBuilder("-g:");
		for (DebugType type: debugList)
		{
			switch (type)
			{
				case LINES:
				{
					debugLine.append("lines,");
					break;
				}
				case SOURCE:
				{
					debugLine.append("source,");
					break;
				}
				case VARIABLES:
				{
					debugLine.append("vars,");
					break;
				}
				default:
					throw new AssertionError(type);
			}
		}
		if (!debugList.isEmpty())
		{
			debugLine.deleteCharAt(debugLine.length() - ",".length());
			options.add(debugLine.toString());
		}
		options.add("-s");
		options.add(targetDirectory.getPath());
		options.add("-d");
		options.add(targetDirectory.getPath());
		final Writer output = null;
		final javax.tools.JavaCompiler.CompilationTask task = compiler.getTask(output, fileManager,
			diagnostics, options, null, compilationUnits);
		final boolean result = task.call();
		try
		{
			printDiagnostics(diagnostics, uniqueSourcePath, uniqueSourceFiles, targetDirectory);
		}
		catch (IOException e)
		{
			throw new BuildException(e);
		}
		if (!result)
			throw new BuildException();
		URLClassLoader loader = AccessController.doPrivileged(new PrivilegedAction<URLClassLoader>()
		{
			@Override
			public URLClassLoader run()
			{
				return new URLClassLoader(classPath.toArray(new URL[0]),
					Thread.currentThread().getContextClassLoader());
			}
		});
		try
		{
			saveDependencyCache(modifiedFiles, uniqueSourcePath, loader);
			fileManager.close();
		}
		catch (IOException e)
		{
			throw new BuildException(e);
		}
	}

	/**
	 * Reads a previously-saved dependency cache.
	 */
	private void loadDependencyCache()
	{
		ObjectInputStream in = null;
		try
		{
			in = new ObjectInputStream(new FileInputStream("dependencies.ser"));
			dependencyCache = (DependencyCache) in.readObject();
		}
		catch (ClassNotFoundException e)
		{
			throw new AssertionError(e);
		}
		catch (IOException unused)
		{
			dependencyCache = new DependencyCache();
		}
		finally
		{
			if (in != null)
			{
				try
				{
					in.close();
				}
				catch (IOException ignore)
				{
				}
			}
		}
	}

	/**
	 * Updates the dependency information for a set of source-code files.
	 *
	 * @param sourceFiles the source files that were modified
	 * @param sourcePath the source file search path
	 * @param loader the class-loader
	 * @throws FileNotFoundException if a source file is missing
	 * @throws IOException if an I/O error occurs while parsing the source files
	 */
	private void saveDependencyCache(Set<File> sourceFiles, Set<File> sourcePath,
																	 ClassLoader loader)
		throws FileNotFoundException, IOException
	{
		for (File sourceFile: sourceFiles)
		{
			Set<String> classesInFile = JavaSource.getClasses(sourceFile);
			FileNode fileNode = dependencyCache.files.get(sourceFile);
			fileNode.lastCompiled = sourceFile.lastModified();
			fileNode.classes = classesInFile;
			Set<String> dependencies = JavaSource.getDependencies(sourceFile, sourcePath, loader);
			for (String classInFile: classesInFile)
				dependencyCache.classes.get(classInFile).dependencies.addAll(dependencies);
		}
		ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("dependencies.ser"));
		try
		{
			out.writeObject(dependencyCache);
		}
		finally
		{
			out.close();
		}
	}

	/**
	 * Returns the java source-code file corresponding to a class name.
	 *
	 * @param className the fully-qualified class name to look up
	 * @param sourcePath the source-code search path
	 * @return null if no match was found
	 */
	private static File getJavaSource(String className, Set<File> sourcePath)
	{
		// TODO: check for class files instead of source
		for (File path: sourcePath)
		{
			File result = classNameToFile(path, className);
			if (!result.exists())
				continue;
			return result;
		}
		return null;
	}

	/**
	 * Converts a class name to its source-code file.
	 *
	 * @param sourcePath the source-code search path
	 * @param className the fully-qualified class name
	 * @return the source-code file
	 */
	private static File classNameToFile(File sourcePath, String className)
	{
		return new File(sourcePath, className.replace(".", "/") + ".java");
	}

	/**
	 * Displays any compilation errors.
	 *
	 * @param diagnostics the compiler diagnostics
	 * @param sourcePath the source file search path
	 * @param sourceFiles the source files to compile
	 * @param targetDirectory the directory to compile into
	 * @throws IOException if an I/O error occurs
	 */
	private void printDiagnostics(final DiagnosticCollector<JavaFileObject> diagnostics,
																final Set<File> sourcePath, final Set<File> sourceFiles,
																final File targetDirectory)
		throws IOException
	{
		int errors = 0;
		int warnings = 0;
		boolean firstTime = true;
		for (Diagnostic<? extends JavaFileObject> diagnostic: diagnostics.getDiagnostics())
		{
			if (firstTime)
			{
				firstTime = false;
				System.out.print("Invoking: ");
				for (String token: toCommandLine(sourceFiles, targetDirectory, sourcePath))
					System.out.print(token + " ");
				System.out.println();
				System.err.println();
			}
			JavaFileObject source = diagnostic.getSource();
			if (source == null)
				System.err.println(diagnostic.getMessage(null));
			else
			{
				System.err.print(source.getName());
				if (diagnostic.getLineNumber() != Diagnostic.NOPOS)
					System.err.print(":" + diagnostic.getLineNumber());
				System.err.println(": " + diagnostic.getMessage(null));
				//BufferedReader reader = new BufferedReader(source.openReader(false));
				BufferedReader reader = new BufferedReader(new InputStreamReader(source.openInputStream()));
				try
				{
					String line = null;
					for (long lineNumber = 0, size = diagnostic.getLineNumber(); lineNumber < size;
							 ++lineNumber)
					{
						line = reader.readLine();
						if (line == null)
							break;
					}
					if (line != null)
					{
						System.err.println(line);
						for (long i = 1, size = diagnostic.getColumnNumber(); i < size; ++i)
							System.err.print(" ");
						System.err.println("^");
					}
				}
				finally
				{
					reader.close();
				}
				System.err.println();
			}
			switch (diagnostic.getKind())
			{
				case ERROR:
				{
					++errors;
					break;
				}
				case NOTE:
				case OTHER:
				case WARNING:
				case MANDATORY_WARNING:
				{
					++warnings;
					break;
				}
				default:
					throw new AssertionError(diagnostic.getKind());
			}
		}
		if (errors > 0)
		{
			System.err.print(errors + " error");
			if (errors > 1)
				System.err.print("s");
			System.err.println();
		}
		if (warnings > 0)
		{
			System.err.print(warnings + " warning");
			if (warnings > 1)
				System.err.print("s");
			System.err.println();
		}
	}

	/**
	 * Returns all changed source-code files that are accepted by the filter.
	 *
	 * @param sourceFiles the source files to compile
	 * @param sourcePath the source file search path
	 * @param targetDirectory the directory to compile into
	 * @param unmodifiedFiles files that are known not to have been modified
	 * @return all changed source-code files that are accepted by the filter
	 * @throws IOException if an I/O error occurs
	 */
	private Set<File> getModifiedFiles(final Set<File> sourceFiles,
																		 final Set<File> sourcePath,
																		 final File targetDirectory,
																		 final Set<File> unmodifiedFiles)
		throws IOException
	{
		Set<File> result = Sets.newHashSet(sourceFiles);
		for (File sourceFile: sourceFiles)
		{
			if (unmodifiedFiles.contains(sourceFile))
			{
				result.remove(sourceFile);
				continue;
			}
			FileNode source = dependencyCache.files.get(sourceFile);

			if (source == null)
				continue;

			if (sourceFile.lastModified() > source.lastCompiled)
				continue;
			boolean targetOutOfDate = false;
			for (String classInFile: source.classes)
			{
				if (sourceFile.lastModified() > JavaSource.getClassFile(classInFile, targetDirectory).
					lastModified())
				{
					targetOutOfDate = true;
					break;
				}
			}
			if (targetOutOfDate)
				continue;
			boolean dependencyModified = false;
			for (String className: source.classes)
			{
				for (String dependency: dependencyCache.classes.get(className).dependencies)
				{
					File dependencyFile = getJavaSource(dependency, sourcePath);
					if (dependencyFile == null)
					{
						log.debug("Cannot find class: " + dependency);
						continue;
					}
					if (!getModifiedFiles(Collections.singleton(dependencyFile), sourcePath, targetDirectory,
						unmodifiedFiles).isEmpty())
					{
						dependencyModified = true;
						break;
					}
				}
				if (!dependencyModified)
					break;
			}
			if (!dependencyModified)
			{
				unmodifiedFiles.add(sourceFile);
				result.remove(sourceFile);
			}
		}
		return result;
	}

	/**
	 * Returns the command-line representation of the object.
	 *
	 * @param sourceFiles the source files to compile
	 * @param targetDirectory the directory to compile into
	 * @param sourcePath the source file search path
	 * @return the command-line representation of the object
	 * @throws IllegalArgumentException if the classpath contains non-file components
	 * @throws IOException if an I/O error occurs
	 */
	private List<String> toCommandLine(final Collection<File> sourceFiles, final File targetDirectory,
																		 final Collection<File> sourcePath)
		throws IOException
	{
		final List<String> result = Lists.newArrayList("javac");
		if (!classPathList.isEmpty())
		{
			result.add("-cp");
			try
			{
				final StringBuilder line = new StringBuilder();
				for (final Iterator<URL> i = classPathList.iterator(); i.hasNext();)
				{
					line.append(Urls.toFile().apply(i.next()).getPath());
					if (i.hasNext())
						line.append(File.pathSeparatorChar);
				}
				result.add(line.toString());
			}
			catch (IllegalArgumentException e)
			{
				// Occurs if URL does not refer to a file
				throw new IllegalStateException(e);
			}
		}
		for (File javaFile: getModifiedFiles(ImmutableSet.copyOf(sourceFiles),
			ImmutableSet.copyOf(sourcePath), targetDirectory, Sets.<File>newHashSet()))
		{
			result.add(javaFile.getPath());
		}
		result.add("-d");
		result.add(targetDirectory.getPath());
		return result;
	}

	@Override
	public String toString()
	{
		return getClass().getName() + "[classPath=" + classPathList + "]";
	}

	/**
	 * The type of debugging information that generated files may contain.
	 *
	 * @author Gili Tzabari
	 */
	public enum DebugType
	{
		/**
		 * No debugging information.
		 */
		NONE,
		/**
		 * Line number information.
		 */
		LINES,
		/**
		 * Local variable information.
		 */
		VARIABLES,
		/**
		 * Source file information.
		 */
		SOURCE
	}
}
